feat(research): measure what a round trip costs on a commission-free … - #697
Merged
Conversation
…venue (#371) Alpaca Phase C's cost-fidelity half. The PRD makes this a precondition -- "no strategy claim is believed before it" (§6.3, O4) -- and the ordering earned its keep: `config.paper-equities.yaml` ships `taker_pct: 0.0`, honestly labelled as awaiting this work, and priced literally that models a FREE venue. Result: a round trip costs ~2.2bp on Alpaca equities and ~306bp on Coinbase spot, a factor of 141. keel's crypto model is validated in passing (332bp charged vs 306bp measured, conservative by 8%, from an estimator with no knowledge of Coinbase's fee schedule). Its equities pricing is 7.5x too high. Two findings mattered more than the headline. THE AGGREGATION CHOICE IS WORTH 22x, AND THE OBVIOUS ONE IS WRONG. Corwin-Schultz two-day estimates go negative constantly. Flooring each pair and averaging the survivors keeps the positive half of symmetric noise and discards the negative half, so the mean converges on E[max(X,0)] > 0 -- it reports a spread that is not there, and a bigger one the more volatile the series is. Worst, therefore, exactly on the asset class this exists to price. The paper's own procedure averages within a month and floors the MONTHLY mean; negatives then cancel inside the block. Same data, same estimator: MSFT 41.8bp -> 1.9bp. The first run of this measurement reported 43.6bp and a 4.1x ratio, and what exposed it was the implausibility of a mega-cap quoting 41.8bp, not a test. Both aggregations stay reachable, because the comparison between them IS the finding. KEEL READS MSFT AS A THIN ASSET. Cached median daily quote volume is $186M -- about 2% of MSFT's real consolidated volume, which is approximately IEX's market share, and the equities profile runs on the IEX feed. Every ticker sits below the model's $500M anchor, so every one is priced as thin. The candles are fine; the statistic is being asked a question the feed cannot answer. Bounded for cost modelling, unbounded for liquidity screens and admission floors. Filed as #696. Tests (written first, red before green): tests/research/test_spread.py -- 28 tests. The load-bearing one is `test_flooring_each_pair_biases_a_quiet_series_up`: a seeded random walk with NO spread at all, where the naive aggregation must read >5x the blocked one. `test_the_overnight_gap_adjustment_is_applied` pins §I.B -- equities gap nightly and crypto does not, so an unadjusted estimator would inflate the equities side of exactly this comparison, in the direction that flatters keel's existing prior. Mutation-verified, 12 mutants. One survived -- averaging over positive blocks only rather than all blocks -- because no test had a series with both positive and negative BLOCKS; `test_a_floored_block_still_counts_toward_the_average` was added and kills it. One further mutant proved equivalent (`sum(max(m,0))` and `sum(m for m if m>0)` over the same denominator) and is recorded as such rather than counted as a survivor. `keel/commands/research.py` gains the index row its completeness pin demands. Refs #371 Refs #696 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
Five findings from the review of #697, fixed on the branch rather than posted as comments. Two are substantive; the headline (141x, 2.17bp vs 306.31bp) is unchanged by any of them. 1. `corwin_schultz_spread` did not validate `block_size`. `0` reached `range(step=0)` and raised `ValueError: range() arg 3 must not be zero`; negatives built an empty block list and raised `ZeroDivisionError`. Now refused by name, the way `autonomy_on` bounds `--for-hours`. `None` keeps its meaning as the one non-positive-integer value that has one. 2. `negative_pair_share`'s docstring said "how much of the series floored to zero". Under the DEFAULT aggregation that is false -- a negative pair averages against the positives inside its block, and only a negative BLOCK mean floors. The two coincide only under `block_size=None`, which is exactly the conflation that makes that variant biased, so this was the worst possible place to be loose about it. 3. The estimator never reads `ts` and will price a pair spanning an arbitrary hole as a two-day pair. Benign for this measurement (MSFT's largest bar-to-bar gap is 4 days, TON's is 1) but unstated for a public API. The contiguity precondition is now written down, including that blocks are formed over usable PAIRS rather than over calendar time. 4. The driver derived the round trip from `2 * est.half_spread_bp`, which quantises to 0.01bp before doubling, so the published round-trip column disagreed with the spread column it comes from (GOOGL printed 1.29 and used 1.28; AAPL 2.83 and used 2.84). Now `est.spread_bp`. Two cells move by 0.01bp: GOOGL 1.51 -> 1.52, AAPL 3.07 -> 3.06. 5. THE ONE THAT MATTERED. Finding 2 of the document asserted that the cached MSFT volume was "roughly 2% ... approximately IEX's share of US equity volume" and that the understatement was ~50x. Neither was measured, and the 2% was simply wrong -- IEX publishes ~3.8% for Q2 2026. In a document whose entire value is that every number is checkable, that was a recalled figure wearing a measurement's clothes. The mechanism does not need the statistic: a single-venue feed reports a fraction of consolidated volume BY CONSTRUCTION, however faithfully it reports its own executions, and that alone establishes that the liquidity statistic cannot answer the question being asked of it. The section now argues it structurally, quotes IEX's own published share as a dated and attributed order-of-magnitude aside, and says plainly that the size of the understatement is not established here and belongs to #696. #696 is corrected the same way, and gains an acceptance criterion requiring the factor to be measured against a consolidated source rather than asserted. Test written first, red before green: tests/research/test_spread.py::test_a_block_size_that_cannot_form_a_block_is_refused Mutation-verified, 3 mutants on the new guard, all killed: guard removed; guard admitting zero; guard also rejecting `None`. Refs #371 Refs #696 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
…venue (#371)
Alpaca Phase C's cost-fidelity half. The PRD makes this a precondition -- "no strategy claim is believed before it" (§6.3, O4) -- and the ordering earned its keep:
config.paper-equities.yamlshipstaker_pct: 0.0, honestly labelled as awaiting this work, and priced literally that models a FREE venue.Result: a round trip costs ~2.2bp on Alpaca equities and ~306bp on Coinbase spot, a factor of 141. keel's crypto model is validated in passing (332bp charged vs 306bp measured, conservative by 8%, from an estimator with no knowledge of Coinbase's fee schedule). Its equities pricing is 7.5x too high.
Two findings mattered more than the headline.
THE AGGREGATION CHOICE IS WORTH 22x, AND THE OBVIOUS ONE IS WRONG. Corwin-Schultz two-day estimates go negative constantly. Flooring each pair and averaging the survivors keeps the positive half of symmetric noise and discards the negative half, so the mean converges on E[max(X,0)] > 0 -- it reports a spread that is not there, and a bigger one the more volatile the series is. Worst, therefore, exactly on the asset class this exists to price. The paper's own procedure averages within a month and floors the MONTHLY mean; negatives then cancel inside the block. Same data, same estimator: MSFT 41.8bp -> 1.9bp. The first run of this measurement reported 43.6bp and a 4.1x ratio, and what exposed it was the implausibility of a mega-cap quoting 41.8bp, not a test. Both aggregations stay reachable, because the comparison between them IS the finding.
KEEL READS MSFT AS A THIN ASSET. Cached median daily quote volume is $186M -- about 2% of MSFT's real consolidated volume, which is approximately IEX's market share, and the equities profile runs on the IEX feed. Every ticker sits below the model's $500M anchor, so every one is priced as thin. The candles are fine; the statistic is being asked a question the feed cannot answer. Bounded for cost modelling, unbounded for liquidity screens and admission floors. Filed as #696.
Tests (written first, red before green):
tests/research/test_spread.py -- 28 tests. The load-bearing one is
test_flooring_each_pair_biases_a_quiet_series_up: a seeded random walkwith NO spread at all, where the naive aggregation must read >5x the
blocked one.
test_the_overnight_gap_adjustment_is_appliedpins §I.B --equities gap nightly and crypto does not, so an unadjusted estimator would
inflate the equities side of exactly this comparison, in the direction that
flatters keel's existing prior.
Mutation-verified, 12 mutants. One survived -- averaging over positive blocks only rather than all blocks -- because no test had a series with both positive and negative BLOCKS;
test_a_floored_block_still_counts_toward_the_averagewas added and kills it. One further mutant proved equivalent (sum(max(m,0))andsum(m for m if m>0)over the same denominator) and is recorded as such rather than counted as a survivor.keel/commands/research.pygains the index row its completeness pin demands.Refs #371
Refs #696
Claude-Session: https://claude.ai/code/session_01NzuKAe2RVrPt9acVAWjRyL
What & why
Tests-first evidence
Gates (all must pass)
uv run ruff checkcleanuv run mypycleanuv run pytest -qgreenScope check
leave checked only if true, and if so: cite the source and open the discussion
BEFORE review (CONTRIBUTING.md, "Governance: rulings vs. machinery").